/*
 *******************************************************************************
 *
 * Copyright (C) 2019-2020 Dialog Semiconductor.
 * This computer program includes Confidential, Proprietary Information
 * of Dialog Semiconductor. All Rights Reserved.
 *
 *******************************************************************************
 */

package com.example.suota.suota.suota;

import android.content.Context;
import android.content.res.AssetManager;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;

import androidx.documentfile.provider.DocumentFile;

import com.example.suota.suota.global.SuotaLibConfig;
import com.example.suota.suota.global.SuotaLibLog;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.zip.CRC32;

/**
 * Representation of a firmware file.
 * Simplifies the update process using this, instead of java.io.File,
 * by breaking down the file into chunks and blocks to be sent and calculating CRC
 */
public class SuotaFile {

    private static final String TAG = "SuotaFile";

    private File file;
    private Uri uri;
    private DocumentFile documentFile;
    private AssetManager assets;
    private String assetName;
    private String filename;
    private HeaderInfo headerInfo;
    private int firmwareSize;
    private int blockSize = SuotaLibConfig.Default.BLOCK_SIZE;
    private int chunkSize = SuotaLibConfig.Default.CHUNK_SIZE;
    private int totalBlocks;
    private int totalChunks;
    private int chunksPerBlock;
    private int lastBlockSize;
    private byte[] data;
    private byte[][][] blocks;
    private int crc;

    /**
     * Creates a new SuotaFile instance by opening the given File
     *
     * @param file file
     */
    public SuotaFile(File file) {
        this.file = file;
        this.filename = file.getName();
    }

    /**
     * Creates a new SuotaFile instance by opening the file with the given filename in the default directory.
     * Default directory is the ExternalStorageDir/Suota.
     *
     * @param fileName file name
     */
    public SuotaFile(String fileName) {
        this(new File(fileName.startsWith(File.separator) ? fileName : SuotaLibConfig.Default.FIRMWARE_PATH + File.separator + fileName));
    }

    /**
     * Creates a new SuotaFile instance by opening the given path and filename
     *
     * @param path path
     * @param fileName file name
     */
    public SuotaFile(String path, String fileName) {
        this(new File(path + File.separator + fileName));
    }

    /**
     * Creates a new SuotaFile instance by opening the given file URI
     *
     * @param uri URI of the firmware file
     */
    public SuotaFile(Context context, Uri uri) {
        this.uri = uri;
        documentFile = DocumentFile.fromSingleUri(context.getApplicationContext(), uri);
        filename = documentFile.getName();
    }

    /**
     * Creates a new SuotaFile instance by opening the given document file
     *
     * @param documentFile firmware document file
     */
    public SuotaFile(DocumentFile documentFile) {
        this.documentFile = documentFile;
        uri = documentFile.getUri();
        filename = documentFile.getName();
    }

    /**
     * Creates a new SuotaFile instance by opening the given asset
     *
     * @param assetName name of the asset
     */
    public SuotaFile(AssetManager assets, String assetName) {
        this.assets = assets;
        this.assetName = assetName;
        filename = assetName.substring(assetName.lastIndexOf(File.separatorChar) + 1);
    }

    /**
     * Creates a new SuotaFile instance from the given buffer
     *
     * @param firmware firmware raw buffer
     */
    public SuotaFile(byte[] firmware) {
        firmwareSize = firmware.length;
        data = new byte[firmwareSize + 1]; // plus one for CRC
        System.arraycopy(firmware, 0, data, 0, firmware.length);
        crc = calculateCrc();
        data[firmwareSize] = (byte) crc;
    }

    /**
     * Creates the default Suota directory to the ExternalStorageDir.
     * If such directory already exists the method doest nothing.
     *
     * @return <b>true</b> if the directory was created, along with all necessary parent directories or if it already exists
     */
    public static boolean createDirectory() {
        File directory = new File(SuotaLibConfig.Default.FIRMWARE_PATH);
        return directory.exists() || directory.mkdirs();
    }

    /**
     * Returns a list of SuotaFile objects found in the given list of paths,
     * with the given extension, sorted by the File's name.
     *
     * @param pathList list of paths to search for files
     * @param extension extension the files must have in order to be included to the returned list.
     *                  If null, files with any extensions are included to the returned list.
     * @param searchSubFolders indicating if the methods is going to search for files to each sub-folder of paths of the pathList
     *                         if false, only the paths' sub-folders will be ignored
     * @param withHeaderInfo indicating if the method is going to initialize the header info of each file.
     *                       if false, header info initialization will be ignored
     * @return List of SuotaFile found meeting the requirements
     */
    public static List<SuotaFile> listFiles(List<String> pathList, String extension, boolean searchSubFolders, boolean withHeaderInfo) {
        if (SuotaLibLog.SUOTA_FILE)
            Log.d(TAG, "List files" + (extension != null ? " (*" + extension + ")" : "") + ": " + pathList + (searchSubFolders ? " (recursive)" : ""));

        List<SuotaFile> files = new ArrayList<>();

        for (String path : pathList) {
            File directory = new File(path);
            if (!directory.exists() || !directory.isDirectory())
                continue;

            File[] currentDirFiles = directory.listFiles();
            if (currentDirFiles == null || currentDirFiles.length == 0)
                continue;

            for (File file : currentDirFiles) {
                if (file.isFile()) {
                    if (extension != null && !file.getName().toLowerCase().endsWith(extension.toLowerCase()))
                        continue;
                    SuotaFile suotaFile = new SuotaFile(file);
                    if (withHeaderInfo) {
                        suotaFile.setHeaderInfo(HeaderInfo.create(suotaFile.file));
                    }
                    files.add(suotaFile);
                } else if (file.isDirectory() && searchSubFolders) {
                    files.addAll(listFiles(file.getPath(), extension, true, withHeaderInfo));
                }
            }
        }

        Collections.sort(files, (a, b) -> a.getFilename().compareToIgnoreCase(b.getFilename()));

        if (SuotaLibLog.SUOTA_FILE)
            Log.d(TAG, "Found " + files.size() + " files");
        return files;
    }

    public static List<SuotaFile> listFiles(Context context, DocumentFile path, String extension, boolean searchSubFolders, boolean withHeaderInfo) {
        if (SuotaLibLog.SUOTA_FILE)
            Log.d(TAG, "List files" + (extension != null ? " (*" + extension + ")" : "") + ": " + path.getUri() + (searchSubFolders ? " (recursive)" : ""));

        List<SuotaFile> files = new ArrayList<>();

        if (path.exists() && path.isDirectory()) {
            for (DocumentFile file : path.listFiles()) {
                if (file.isFile()) {
                    if (extension != null && !file.getName().toLowerCase().endsWith(extension.toLowerCase()))
                        continue;
                    SuotaFile suotaFile = new SuotaFile(file);
                    if (withHeaderInfo) {
                        suotaFile.setHeaderInfo(HeaderInfo.create(context, suotaFile.documentFile));
                    }
                    files.add(suotaFile);
                } else if (file.isDirectory() && searchSubFolders) {
                    files.addAll(listFiles(context, file, extension, true, withHeaderInfo));
                }
            }
        }

        Collections.sort(files, (a, b) -> a.getFilename().compareToIgnoreCase(b.getFilename()));

        if (SuotaLibLog.SUOTA_FILE)
            Log.d(TAG, "Found " + files.size() + " files");
        return files;
    }

    public static List<SuotaFile> listFiles(Context context, Uri pathUri, String extension, boolean searchSubFolders, boolean withHeaderInfo) {
        return listFiles(context, DocumentFile.fromTreeUri(context.getApplicationContext(), pathUri), extension, searchSubFolders, withHeaderInfo);
    }

    /**
     * Returns a list of SuotaFile objects found in the given list of paths,
     * sorted by the File's name. Sub-folders of paths are being ignored.
     * Header info initialization is being ignored.
     *
     * @see #listFiles(List, String, boolean, boolean)
     * @param pathList list of paths to search for files
     * @return List of SuotaFile found
     */
    public static List<SuotaFile> listFiles(List<String> pathList) {
        return listFiles(pathList, null, false, SuotaLibConfig.Default.FILE_LIST_HEADER_INFO);
    }

    /**
     * Returns a list of SuotaFile objects found in the given path,
     * sorted by the File's name. Sub-folders of paths are being ignored.
     *
     * @see #listFiles(List, String, boolean, boolean)
     * @param path path to search for files
     * @param extension extension the files must have in order to be included to the returned list.
     *                  If null, files with any extensions are included to the returned list.
     * @param searchSubFolders indicating if the methods is going to search for files to each sub-folder of the path
     *                         if false, only the path's sub-folders will be ignored
     * @param withHeaderInfo indicating if the method is going to initialize the header info of each file.
     *                       if false, header info initialization will be ignored
     * @return List of SuotaFile found
     */
    public static List<SuotaFile> listFiles(String path, String extension, boolean searchSubFolders, boolean withHeaderInfo) {
        return listFiles(Collections.singletonList(path), extension, searchSubFolders, withHeaderInfo);
    }

    /**
     * Returns a list of SuotaFile objects found in the given path,
     * sorted by the File's name. Sub-folders of paths are being ignored.
     * Header info initialization is being ignored.
     *
     * @see #listFiles(List, String, boolean, boolean)
     * @param path path to search for files
     * @return List of SuotaFile found
     */
    public static List<SuotaFile> listFiles(String path) {
        return listFiles(path, null, false, SuotaLibConfig.Default.FILE_LIST_HEADER_INFO);
    }

    public static List<SuotaFile> listFiles(Context context, DocumentFile path) {
        return listFiles(context, path, null, false, SuotaLibConfig.Default.FILE_LIST_HEADER_INFO);
    }

    /**
     * Returns a list of SuotaFile objects found in the given path,
     * sorted by the File's name. Sub-folders of the path are being ignored.
     * Header info initialization is being being ignored if the associated param is set to false.
     *
     * @see #listFiles(List, String, boolean, boolean)
     * @param path path to search for files
     * @param withHeaderInfo flag indicating whether header info initialization will take place
     * @return List of SuotaFile found
     */
    public static List<SuotaFile> listFiles(String path, boolean withHeaderInfo) {
        return listFiles(path, null, false, withHeaderInfo);
    }

    public static List<SuotaFile> listFiles(Context context, DocumentFile path, boolean withHeaderInfo) {
        return listFiles(context, path, null, false, withHeaderInfo);
    }

    /**
     * Returns a list of SuotaFile objects found in the given path,
     * sorted by the File's name. Sub-folders of paths are being ignored.
     *
     * @see #listFiles(List, String, boolean, boolean)
     * @param path path to search for files
     * @param extension extension the files must have in order to be included to the returned list.
     *                  If null, files with any extensions are included to the returned list.
     * @return List of SuotaFile found
     */
    public static List<SuotaFile> listFiles(String path, String extension) {
        return listFiles(path, extension, false, SuotaLibConfig.Default.FILE_LIST_HEADER_INFO);
    }

    public static List<SuotaFile> listFiles(Context context, DocumentFile path, String extension) {
        return listFiles(context, path, extension, false, SuotaLibConfig.Default.FILE_LIST_HEADER_INFO);
    }

    /**
     * Returns a list of File objects found in the given path,
     * sorted by the File's name. Sub-folders of paths are being ignored.
     *
     * @see #listFiles(List, String, boolean, boolean)
     * @param path path to search for files
     * @param extension extension the files must have in order to be included to the returned list.
     *                  If null, files with any extensions are included to the returned list.
     * @param searchSubFolders indicating if the methods is going to search for files to each sub-folder of the path
     *                         if false, only the path's sub-folders will be ignored
     * @return List of Files found
     */
    public static List<SuotaFile> listFiles(String path, String extension, boolean searchSubFolders) {
        return listFiles(path, extension, searchSubFolders, SuotaLibConfig.Default.FILE_LIST_HEADER_INFO);
    }

    public static List<SuotaFile> listFiles(Context context, DocumentFile path, String extension, boolean searchSubFolders) {
        return listFiles(context, path, extension, searchSubFolders, SuotaLibConfig.Default.FILE_LIST_HEADER_INFO);
    }

    /**
     * Returns a list of File objects found in the default directory
     * Default directory is the ExternalStorageDir/Suota.
     *
     * @see #listFiles(List, String, boolean, boolean)
     * @return List of Files found
     */
    public static List<SuotaFile> listFiles() {
        return listFiles(SuotaLibConfig.Default.FIRMWARE_PATH);
    }

    public static List<SuotaFile> listFiles(Context context) {
        return SuotaManager.isFilePathValid() ? listFiles(context, SuotaManager.getFilePath()) : Collections.emptyList();
    }

    /**
     * List files within default file dir, and return a HashMap keyed by the files' name
     * and containing as values the associated header info object
     * @return the HashMap described
     */
    public static List<SuotaFile> listFilesWithHeaderInfo() {
        return listFiles(SuotaLibConfig.Default.FIRMWARE_PATH, true);
    }

    public static List<SuotaFile> listFilesWithHeaderInfo(Context context) {
        return SuotaManager.isFilePathValid() ? listFiles(context, SuotaManager.getFilePath(), true) : Collections.emptyList();
    }

    public File getFile() {
        return file;
    }

    public Uri getUri() {
        return uri;
    }

    public DocumentFile getDocumentFile() {
        return documentFile;
    }

    public String getAssetName() {
        return assetName;
    }

    /**
     * Returns the name of the file
     * @return name of the file
     */
    public String getFilename() {
        return !TextUtils.isEmpty(filename) ? filename : "Unknown";
    }

    public HeaderInfo getHeaderInfo() {
        return headerInfo;
    }

    public void setHeaderInfo(HeaderInfo headerInfo) {
        this.headerInfo = headerInfo;
    }

    public boolean hasHeaderInfo() {
        return headerInfo != null;
    }

    public int getFileSize() {
        return file != null ? (int) file.length() : documentFile != null ? (int) documentFile.length() : 0;
    }

    public int getFirmwareSize() {
        return firmwareSize;
    }

    public int getUploadSize() {
        return data.length;
    }

    public int getBlockSize() {
        return blockSize;
    }

    public void setBlockSize(int blockSize) {
        this.blockSize = blockSize;
    }

    public int getChunkSize() {
        return chunkSize;
    }

    void setChunkSize(int chunkSize) {
        this.chunkSize = chunkSize;
    }

    public int getTotalBlocks() {
        return totalBlocks;
    }

    public int getTotalChunks() {
        return totalChunks;
    }

    public int getChunksPerBlock() {
        return chunksPerBlock;
    }

    public boolean isLastBlockShorter() {
        return lastBlockSize != 0;
    }

    public int getLastBlockSize() {
        return lastBlockSize;
    }

    public byte[][] getBlock(int index) {
        return blocks[index];
    }

    public byte[] getChunk(int index) {
        int block = index / chunksPerBlock;
        int chunk = index % chunksPerBlock;
        return blocks[block][chunk];
    }

    public int getBlockSize(int index) {
        return index < totalBlocks - 1 || lastBlockSize == 0 ? blockSize : lastBlockSize;
    }

    public int getBlockChunks(int index) {
        return blocks[index].length;
    }

    public boolean isLastBlock(int index) {
        return index == totalBlocks - 1;
    }

    public boolean isLastChunk(int index) {
        return index % chunksPerBlock == blocks[index / chunksPerBlock].length - 1;
    }

    public boolean isLastChunk(int block, int chunk) {
        return chunk == blocks[block].length - 1;
    }

    public int getCrc() {
        return crc;
    }

    public void load(Context context) {
        if (isLoaded())
            return;

        InputStream inputStream = null;
        try {
            if (file != null) {
                firmwareSize = (int) file.length();
                inputStream = new FileInputStream(file);
            } else if (documentFile != null) {
                firmwareSize = (int) documentFile.length();
                inputStream = context.getContentResolver().openInputStream(uri);
            } else {
                inputStream = assets.open(assetName);
                firmwareSize = inputStream.available();
            }
            data = new byte[firmwareSize + 1];  // plus one for CRC
            int offset = 0, count;
            while (offset != firmwareSize && (count = inputStream.read(data, offset, firmwareSize - offset)) != -1) {
                offset += count;
            }
        } catch (IOException | SecurityException e) {
            Log.e(TAG, "Failed to load firmware: " + (file != null ? file.getAbsolutePath() : documentFile != null ? uri : assetName), e);
            data = null;
            return;
        } finally {
            try {
                if (inputStream != null)
                    inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        crc = calculateCrc();
        data[firmwareSize] = (byte) crc;
    }

    public void load() {
        load(null);
    }

    public boolean isLoaded() {
        return data != null;
    }

    private int calculateCrc() {
        int crc = 0;
        for (int i = 0; i < firmwareSize; i++) {
            crc ^= data[i];
        }
        crc &= 0xff;
        return crc;
    }

    private long calculatePayloadCrc() {
        CRC32 crc32 = new CRC32();
        crc32.update(data, (int)headerInfo.getPayloadOffset(), (int)headerInfo.getPayloadSize());
        return crc32.getValue();
    }

    public boolean isHeaderCrcValid() {
        return firmwareSize - headerInfo.getPayloadOffset() >= headerInfo.getPayloadSize() && headerInfo.getPayloadCrc() == calculatePayloadCrc();
    }

    public void initBlocks(int blockSize, int chunkSize) {
        this.blockSize = blockSize;
        this.chunkSize = chunkSize;
        initBlocks();
    }

    public void initBlocks() {
        if (blockSize < chunkSize)
            blockSize = chunkSize;
        if (blockSize > data.length) {
            blockSize = data.length;
            if (chunkSize > blockSize)
                chunkSize = blockSize;
        }

        totalBlocks = data.length / blockSize + (data.length % blockSize != 0 ? 1 : 0);
        chunksPerBlock = blockSize / chunkSize + (blockSize % chunkSize != 0 ? 1 : 0);
        lastBlockSize = data.length % blockSize;
        totalChunks = 0;

        // Loop through all the bytes and split them into blocks and chunks
        blocks = new byte[totalBlocks][][];
        int offset = 0;
        for (int i = 0; i < totalBlocks; i++) {
            int currBlockSize = blockSize;
            int chunksInBlock = chunksPerBlock;
            // Check if the last block needs to be smaller
            if (offset + blockSize > data.length) {
                currBlockSize = data.length % blockSize;
                chunksInBlock = currBlockSize / chunkSize + (currBlockSize % chunkSize != 0 ? 1 : 0);
            }
            blocks[i] = new byte[chunksInBlock][];
            for (int j = 0; j < chunksInBlock; j++) {
                // Default chunk size
                int currChunkSize = chunkSize;
                // Check if last chunk in block needs to be smaller
                if ((j + 1) * chunkSize > currBlockSize) {
                    currChunkSize = currBlockSize % chunkSize;
                }
                blocks[i][j] = Arrays.copyOfRange(data, offset, offset + currChunkSize);
                offset += currChunkSize;
                totalChunks++;
            }
        }
    }
}
